package p3.cse;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.security.spec.KeySpec;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.regex.Pattern;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.InflaterInputStream;
import java.util.zip.ZipException;

import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.CipherOutputStream;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;

import android.util.Log;

/**
 * A class for maintaining the representation of the password database and
 * performing secure access to its storage on disk. Currently, this is performed
 * using AES symmetric encryption with the user password hashed using SHA-256 to
 * form the key.
 * 
 * <pre>
 * +-------------------------------+-------------------------------+
 * |     initialization vector     |              salt             |
 * +-------------------------------+-------------------------------+
 * |                          salted hash                          |
 * +---------------------------------------------------------------+
 * |                           encrypted                           |
 * |                             data                              |
 * +---------------------------------------------------------------+
 * </pre>
 */
public final class Filer implements Iterable<Entry> {
	/**
	 * The name and location of the file used to store the password database.
	 */
	public static final File DB_FILE = new File("/data/data/p3.cse/",
			"dbmain.txt");

	/**
	 * Length in bytes of the initialization vector for AES encryption. This
	 * value should only be adjusted if the encryption algorithm is changed.
	 */
	private static final int IV_SIZE = 16;

	/**
	 * Length in bytes of the salt used for hashing the master password. The
	 * length is not important, but a "round" number with respect to base-2
	 * makes file format offsets simple.
	 */
	private static final int SALT_SIZE = 16;

	/**
	 * The number of rounds to repeat the hashing algorithm.
	 */
	private static final int ITERATIONS = 1000;

	/**
	 * Global copy of the {@code Filer} class accessible to all parts of the
	 * program.
	 */
	private static Filer singleton = new Filer();

	/**
	 * Internal storage for the master password.
	 */
	private String master;

	/**
	 * Internal container for account entries.
	 */
	private List<Entry> entries;

	/**
	 * The default constructor has been overridden to ensure the singleton is
	 * only accessible through the getInstance method.
	 */
	private Filer() {
		Log.d("Filer", "Initializing filer from scratch.");
	}

	/**
	 * Accesses the globally accessible copy of the Filer class.
	 * 
	 * @return either a new {@code Filer} if it has not been initialized, or the
	 *         existing {@code Filer} object
	 */
	public static Filer getInstance() {
		return singleton;
	}

	/**
	 * Attempts to use the provided master password to read the password
	 * database and parse it into a representation.
	 * 
	 * @param password
	 *            the user's master password for decrypting the database
	 * @throws IOException
	 *             indicates a problem has occurred with accessing the password
	 *             database file
	 * @see ReadFile
	 */
	public void init(final String password) throws IOException {
		master = password;
		entries = new LinkedList<Entry>();
		if (Filer.DB_FILE.exists()) {
			Log.d("Filer", "Password database exists, attempting to parse.");
			readFile();
		}
	}

	/**
	 * Attempts to determine if the provided password matches the what is used
	 * to encrypt the password database.
	 * 
	 * @param password
	 *            the password string to check
	 * @return {@code true} if the password provided and the password for the
	 *         database match, and false otherwise
	 * @throws IOException
	 *             indicates either the file does not exist or there was a
	 *             problem reading
	 */
	public boolean checkPassword(final String password) throws IOException {
		Log.d("Filer", "Verifying password: " + password);
		FileInputStream raw = new FileInputStream(DB_FILE);
		byte[] iv = new byte[IV_SIZE], salt = new byte[SALT_SIZE];
		raw.read(iv);
		Log.d("Filer", "Reading IV: " + Arrays.toString(iv));
		raw.read(salt);
		Log.d("Filer", "Reading salt: " + Arrays.toString(salt));

		Cipher algo = null;
		try {
			SecretKeyFactory factory = SecretKeyFactory
					.getInstance("PBKDF2WithHmacSHA1");
			PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt,
					ITERATIONS, 256);
			SecretKey tmp = factory.generateSecret(spec);
			SecretKey secret = new SecretKeySpec(tmp.getEncoded(), "AES");
			algo = Cipher.getInstance("AES/CBC/PKCS5Padding");
			algo.init(Cipher.DECRYPT_MODE, secret, new IvParameterSpec(iv));
		} catch (GeneralSecurityException e) {
			Log.e("Filer", "Incorrectly configured crypto algorithm.", e);
		}
		BufferedReader in = new BufferedReader(new InputStreamReader(
				new InflaterInputStream(new CipherInputStream(raw, algo))));
		try {
			in.readLine();
		} catch (ZipException e) {
			return false;
		}

		return true;
	}

	/**
	 * Check whether a password meets minimum requirements for master password.
	 * 
	 * @param password
	 *            String containing lowercase, uppercase, digit, and atleast 8
	 *            characters long.
	 * @return Return null on valid password, String containing informaiton on
	 *         the error otherwise.
	 */
	public static String checkStrength(final String password) {
		if (password.length() < 8) {
			return "The password is too short, or does not meet minimum "
					+ "security requirements";
		}

		// uses Unicode classes to match character types
		if (Pattern.compile("\\p{Digit}").matcher(password).find()
				&& Pattern.compile("\\p{Lower}").matcher(password).find()
				&& Pattern.compile("\\p{Upper}").matcher(password).find()) {
			return null;
		}

		return "Error: new password must contain at least one number, one "
				+ "lowercase character, and one upppercase character";

	}

	/**
	 * Reads the password database and generate a list of entries.
	 * 
	 * @throws IOException
	 *             indicates either the file does not exist or there was a
	 *             problem reading
	 */
	private void readFile() throws IOException {
		Log.d("Filer", "Reading password database.");
		// gather all the non-encrypted information from the beginning
		FileInputStream raw = new FileInputStream(DB_FILE);
		byte[] iv = new byte[IV_SIZE], salt = new byte[SALT_SIZE];
		raw.read(iv);
		Log.d("Filer", "Reading IV: " + Arrays.toString(iv));
		raw.read(salt);
		Log.d("Filer", "Reading salt: " + Arrays.toString(salt));

		Cipher algo = null;
		try {
			SecretKeyFactory factory = SecretKeyFactory
					.getInstance("PBKDF2WithHmacSHA1");
			KeySpec spec = new PBEKeySpec(master.toCharArray(), salt,
					ITERATIONS, 256);
			SecretKey tmp = factory.generateSecret(spec);
			SecretKey secret = new SecretKeySpec(tmp.getEncoded(), "AES");
			algo = Cipher.getInstance("AES/CBC/PKCS5Padding");
			algo.init(Cipher.DECRYPT_MODE, secret, new IvParameterSpec(iv));
		} catch (GeneralSecurityException e) {
			Log.e("Filer", "Incorrectly configured algorithm.", e);
		}
		BufferedReader in = new BufferedReader(new InputStreamReader(
				new InflaterInputStream(new CipherInputStream(raw, algo))));

		String line;
		while ((line = in.readLine()) != null) {
			if (line.length() > 0) {
				String account = line;
				String url = in.readLine();
				String username = in.readLine();
				String password = in.readLine();
				entries.add(new Entry(account, url, username, password));
			}
		}
		Collections.sort(entries);
		in.close();
		Log.d("Filer", "Read in " + entries.size() + " entries.");
	}

	/**
	 * Generates a simple text representation of the entries and writes it to
	 * the password database.
	 * 
	 * @throws IOException
	 *             indicates either the file does not exist or there was a
	 *             problem reading
	 */
	public void writeFile() throws IOException {
		Log.d("Filer", "Writing password database.");
		byte[] salt = new byte[SALT_SIZE];
		SecureRandom r = new SecureRandom();
		r.nextBytes(salt);

		Cipher algo = null;
		try {
			SecretKeyFactory factory = SecretKeyFactory
					.getInstance("PBKDF2WithHmacSHA1");
			PBEKeySpec spec = new PBEKeySpec(master.toCharArray(), salt,
					ITERATIONS, 256);
			SecretKey tmp = factory.generateSecret(spec);
			SecretKey secret = new SecretKeySpec(tmp.getEncoded(), "AES");
			algo = Cipher.getInstance("AES/CBC/PKCS5Padding");
			algo.init(Cipher.ENCRYPT_MODE, secret, r);
		} catch (GeneralSecurityException e) {
			Log.e("Filer", "Incorrectly configured algorithm.", e);
			e.printStackTrace();
		}

		// write the "outer container" information for decrypting
		FileOutputStream raw = new FileOutputStream(DB_FILE);
		Log.d("Filer", "Writing IV: " + Arrays.toString(algo.getIV()));
		raw.write(algo.getIV());
		Log.d("Filer", "Writing salt: " + Arrays.toString(salt));
		raw.write(salt);

		// write the encrypted information
		PrintStream out = new PrintStream(new DeflaterOutputStream(
				new CipherOutputStream(raw, algo)));
		for (Entry elmt : entries) {
			elmt.print(out);
		}
		Log.d("Filer", "Wrote " + entries.size() + " accounts.");
		out.close();
	}

	/**
	 * Attempts to update the master password. The old password must also be
	 * provided.
	 * 
	 * @param oldMaster
	 *            the old password that used to be used as the master password
	 * @param newMaster
	 *            the new password to use as the master password
	 * @return true if the change was successful, or false if the wrong password
	 *         was supplied
	 */
	public boolean changePassword(final String oldMaster, final String newMaster) {
		if (oldMaster.equals(master)) {
			master = newMaster;
			return true;
		} else {
			return false;
		}
	}

	/**
	 * Checks if an account already exists in the currently loaded
	 * representation.
	 * 
	 * @param account
	 *            the account name to lookup
	 * @return Returns {@code true} if the account exists, {@code false}
	 *         otherwise.
	 */
	public boolean checkAccountExists(final String account) {
		for (Entry elmt : entries) {
			if (elmt.account.equals(account)) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Adds a new entry. Entries are sorted alphabetically and adding a new
	 * entry can potentially change the indices of other entries.
	 * 
	 * @param entry
	 *            a complete entry representing a user's account information
	 * @return {@code true} if the entry representation changed as a result of
	 *         the call
	 */
	public boolean addEntry(final Entry entry) {
		boolean result = entries.add(entry);
		Collections.sort(entries);
		return result;
	}

	/**
	 * Removes an entry.
	 * 
	 * @param account
	 *            the human readable account of the entry to remove
	 * @return {@code true} if the entry representation changed as a result of
	 *         the call
	 */
	public boolean removeEntry(final String account) {
		for (Entry elmt : entries) {
			if (elmt.account.equals(account)) {
				return entries.remove(elmt);
			}
		}
		return false;
	}

	/**
	 * Updates an entry. Entries are sorted alphabetically and modifying an
	 * entry can potentially change the indices of other entries.
	 * 
	 * @param account
	 *            the human readable account of the entry to replace
	 * @param entry
	 *            new entry to be stored
	 */
	public void updateEntry(final String account, final Entry entry) {
		Log.d("updating account", "looking up " + account);
		for (Entry elmt : entries) {
			if (elmt.account.equals(account)) {
				Log.d("Found account", account);
				entries.remove(elmt);
				entries.add(entry);
				Collections.sort(entries);
				return;
			}
		}
		Log.d("Filer", "no account");
	}

	/**
	 * Returns the number of entries being tracked.
	 * 
	 * @return the number of entries
	 */
	public int entryCount() {
		return entries.size();
	}

	/**
	 * Returns the entry associated with the provided account name if it exists.
	 * 
	 * @param account
	 *            the human readable account of the entry to retrieve
	 * @return either the requested entry or {@code null} if it was not found
	 */
	public Entry getEntry(final String account) {
		for (Entry elmt : entries) {
			if (elmt.account.equals(account)) {
				return elmt;
			}
		}
		Log.e("Filer", "Account entry not found!");
		return null;
	}

	/**
	 * Returns an iterator suitable for examining all of the account entries
	 * currently being stored.
	 * 
	 * @return an {@code Iterator}
	 */
	public Iterator<Entry> iterator() {
		return entries.iterator();
	}
}
